package io.zbus.data.server.js;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import javax.script.Invocable;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import javax.script.SimpleBindings;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.Assert;

import io.zbus.kit.FileKit;
import io.zbus.kit.HttpKit;
import io.zbus.kit.HttpKit.UrlInfo;
import io.zbus.kit.JsKit;
import io.zbus.kit.JsonKit;
import io.zbus.rpc.InvocationContext;
import io.zbus.rpc.annotation.Route;
import io.zbus.transport.Message;
import jdk.nashorn.api.scripting.ScriptObjectMirror;
 
@Route(exclude = true)
public class JavascriptInvoker {
	
	private static final Logger logger = LoggerFactory.getLogger(JavascriptInvoker.class);
	private static final String PROXY_FILE = "proxy.js";
	private static final String CONF_FILE = "conf.js";
	
	private static final String PRIVATE_MARK = "$";
	
	ScriptEngineManager factory = new ScriptEngineManager();
	ScriptEngine engine = factory.getEngineByName("javascript");
	
	private ScriptObjectMirror jsConfigObject = null;
	private ScriptObjectMirror jsFilter = null;
    private ScriptObjectMirror jsFilterConfig = null;
	private ScriptObjectMirror jsContext = null;
	private ScriptObjectMirror jsFuncProxy = null;
	private ScriptObjectMirror jsNext = null;
    private Map<String, ScriptObjectMirror> jsGlobalFunctions = new HashMap<>();
    private boolean cacheEnabled = false;
	
	private Map<String, Object> javaContext = null;
	private FileKit fileKit = new FileKit(false);
	private String basePath = "."; 
	private String urlPrefix = ""; 
	private String confPath = "conf"; 
	
	private File absoluteBasePath = new File(basePath).getAbsoluteFile();  
	public void setBasePath(String basePath) {
		if(basePath == null) {
			basePath = ".";
		}
		this.basePath = basePath; 
		File file = new File(this.basePath);
		if(file.isAbsolute()) {
			absoluteBasePath = file;
		} else {
			absoluteBasePath = new File(System.getProperty("user.dir"), basePath);
		}
	} 
	
	private void prepareJavaContext(Map<String, Object> context) {
		this.javaContext = new HashMap<>(context.size());
		context.entrySet().forEach(e -> {
			String key = e.getKey();
			Object value = e.getValue();
			if (!(value instanceof JsInvokable)) {
				this.javaContext.put(key, value);
				return;
			}
			
			JsInvokable javaObject = (JsInvokable)value;
			Class<?> clazz = value.getClass();
			Method[] methods = clazz.getDeclaredMethods();
			List<String> methodNames = 
				Arrays.stream(methods).map(m -> m.getName()).collect(Collectors.toList());
			Class<?>[] interfaces = clazz.getInterfaces();
			int length = interfaces.length;
			interfaces = Arrays.copyOf(interfaces, length+1);
			interfaces[length] = JsFeature.class;
			Object proxyObject = Proxy.newProxyInstance(
				clazz.getClassLoader(), 
				interfaces, 
				(proxy, method, args) -> {
					if (method.getName().equals("methods")) {
						return methodNames;
					}
					if (method.getName().equals("callMethod")) {
						Assert.isTrue(args.length == 2, "Arguments length must be 2");
						Assert.isTrue(args[0] instanceof String, "Argument[0] must be string");
						Assert.isTrue(args[1].getClass().isArray(), "Argument[1] must be array");
						// Find method
						String methodName = (String)args[0];
						Object[] trueArgs = (Object[])args[1];
						Method trueMethod = Arrays.stream(methods)
							.filter(m -> m.getParameterCount() == trueArgs.length)
							.filter(m -> m.getName().equals(methodName))
							.findFirst()
							.orElse(null);
						Assert.notNull(trueMethod, "No such method(name="+methodName+", args="+trueArgs.length+")");
						
						// JS Arguments convert to Java Arguments
						Class<?>[] paramTypes = trueMethod.getParameterTypes();
						List<Object> argList = new ArrayList<>();
						for (int i = 0; i < paramTypes.length; i++) {
							Object arg = trueArgs[i];
							argList.add(i, JsonKit.convert(arg, paramTypes[i]));
						}
						// TODO 若性能成为瓶颈，由反射改成直接调用
						Object result = trueMethod.invoke(javaObject, argList.toArray(new Object[]{}));
						// Java result convert to JS result(Json String)
						result = JsonKit.toJSONString(result);
						return result;
					}
					
					return method.invoke(javaObject, args);
				}
			);
			this.javaContext.put(key, proxyObject);
		});
	}
	
	private boolean exists(String... relativePath) {
		Path path = Paths.get(this.absoluteBasePath.getAbsolutePath(), relativePath);
		return path.toFile().exists();
	}
	
	public void init() {
		try {
			if (!exists(confPath, PROXY_FILE)) {
				return;
			}
			//load proxy.js
			engine.eval("load('" + basePath + "/" + confPath + "/" + PROXY_FILE + "')");
            Invocable inv = (Invocable) engine;
            Map<String, ScriptObjectMirror> globalFunctions = this.jsGlobalFunctions;
			this.jsContext = (ScriptObjectMirror)inv.invokeFunction(
				PRIVATE_MARK+"buildContext",
                javaContext,
                globalFunctions,
                this,
                urlPrefix
            );
			
			this.jsNext = (ScriptObjectMirror)this.jsContext.remove("next");
			Assert.state(jsNext instanceof ScriptObjectMirror, "buildContext() return missing next");
			this.jsFuncProxy = (ScriptObjectMirror)this.jsContext.remove("funcProxy");
			Assert.state(jsFuncProxy instanceof ScriptObjectMirror, "buildContext() return missing funcProxy");
			this.jsContext.put("functions", globalFunctions);
			
			if (!exists(confPath, CONF_FILE)) {
				return;
			} 
			
			Object initResult = engine.eval("load('"+basePath+"/"+confPath + "/" + CONF_FILE+"')");
            if (initResult != null) {
                if (!(initResult instanceof ScriptObjectMirror)) {
                    initResult = engine.getBindings(ScriptContext.ENGINE_SCOPE);
                }
                Assert.state(initResult instanceof ScriptObjectMirror, CONF_FILE + " must be a js object");
                this.jsConfigObject = (ScriptObjectMirror) initResult;
            }
			
			if (this.jsConfigObject != null) {
				Object ctxResult = inv.invokeMethod(this.jsConfigObject, "context", this.jsContext);
				if (ctxResult != null) {
                    Assert.state(ctxResult instanceof ScriptObjectMirror, "context() must return js object");
					this.jsContext = (ScriptObjectMirror)ctxResult;
				}
                Object filterConfigResult = inv.invokeMethod(this.jsConfigObject, "configFilter", this.jsContext);
                if (filterConfigResult != null) {
                    Assert.state(filterConfigResult instanceof ScriptObjectMirror, "configFilter() must return js object");
                    this.jsFilterConfig = (ScriptObjectMirror)filterConfigResult;
                }
				this.jsFilter = (ScriptObjectMirror)this.jsConfigObject.get("doFilter");
			} 
		} catch (Exception e) {
			logger.error(e.getMessage(), e);
		} 
	}
	
	@Route("/cache")
	public void cache(boolean cacheEnabled) {
		this.setCacheEnabled(cacheEnabled);
	}

	@Route("/")
	public Object index() throws Exception {
		Message req = InvocationContext.getRequest();
		Message res = InvocationContext.getResponse();
        return invoke(req, res);
	}
	 
    public Object invoke(Message req, Message res) throws Exception {
    	String url = req.getUrl();
        FuncInvoker funcInvoker = this.createFuncInvoker(url);
        if (funcInvoker == null) {
            Message r = new Message();
            r.setStatus(404);
            r.setBody(url+" Not Found");
            return r;
        }
        Object result = funcInvoker.invoke(req, res);
        return JsKit.convert(result);
    }

    private UrlInfo parseUrl(String url) {
        if(url.startsWith(this.urlPrefix)) {
            url = url.substring(this.urlPrefix.length());
        }
        UrlInfo urlInfo = HttpKit.parseUrl(url);
        return urlInfo;
    }

    private String[] parseFileMethod(UrlInfo urlInfo) {
        String urlFile = urlInfo.urlPath;
        if(urlFile == null || urlInfo.pathList.isEmpty()) {
            return new String[] { "index.js", "main" };
        } 

        urlFile = "";
        String method = "main";
        if(urlInfo.pathList.size() > 1) {
	        for(int i=0;i<urlInfo.pathList.size()-1;i++) {
	            urlFile += urlInfo.pathList.get(i) + "/";
	        }
	        method = urlInfo.pathList.get(urlInfo.pathList.size()-1);
	        if(urlFile.length() > 0) {
	        	urlFile = urlFile.substring(0, urlFile.length()-1);
	        }
        } else {
        	urlFile = urlInfo.pathList.get(0);
        }
       
        if(!urlFile.endsWith(".js")) {
            urlFile += ".js";
        }
        return new String[] { urlFile, method };
    }
    
  private String readJs(UrlInfo urlInfo) {
	  String urlFile = this.parseFileMethod(urlInfo)[0];
	  File fullPathFile = new File(absoluteBasePath, urlFile);
	  String file = fullPathFile.getPath();
	  String js = null;
	  try {
	      js = new String(fileKit.loadFileBytes(file));
	  } catch (FileNotFoundException e) {
	      /* ignore */
	  } catch (IOException e) {
	      /* ignore */
	  }
	  return js;
  }
    
    private FuncInvoker createFuncInvoker(String url) throws ScriptException {
        UrlInfo urlInfo = this.parseUrl(url);
        String urlPath = urlInfo.urlPath;
        ScriptObjectMirror func = null;
        if (this.cacheEnabled) {
            func = this.jsGlobalFunctions.get(urlPath);
        }

        final String method;
        ScriptObjectMirror functions = null;
        if (func == null) {
        	String[] fileMethod = this.parseFileMethod(urlInfo);
        	String relativePath = fileMethod[0];
        	method = fileMethod[1]; 
            SimpleBindings bindings = new SimpleBindings();
            String js = this.readJs(urlInfo);
            bindings.put("$js", js);
            bindings.put("$jsname", basePath+"/"+relativePath);
            Object evalResult = engine.eval("load({ script:$js, name:$jsname })", bindings);
            if (!(evalResult instanceof ScriptObjectMirror)) {
                evalResult = bindings.get("nashorn.global");
            }
            Assert.state(evalResult instanceof ScriptObjectMirror, urlPath + " must be a js object");
            functions = (ScriptObjectMirror) evalResult;
            if (!functions.hasMember(method)) {
                return null;
            }

            Object member = functions.getMember(method);
            Assert.state(member instanceof ScriptObjectMirror, urlPath + " must be a js function");
            func = (ScriptObjectMirror) member;
            Assert.state(func.isFunction(), urlPath + " must be a js function");

            if (this.cacheEnabled) {
                jsGlobalFunctions.put(urlPath, func);// 用url作为key,注册到global对象中
            }
        } else {
        	method = null;
        }

        final ScriptObjectMirror self = functions;
        final ScriptObjectMirror targetFunc = func; 
        final String uri = String.join("/", urlInfo.pathList);
        
        return new FuncInvoker() {
            @Override
            public Object invoke(Message req, Message res) throws Exception { 
                return jsFuncProxy.call(jsFuncProxy, targetFunc, req, res, jsContext, self, jsFilter, jsFilterConfig, uri);
            }
        };
    }

    public void setCacheEnabled(boolean cacheEnabled) {
		fileKit.setCacheEnabled(cacheEnabled);
        this.cacheEnabled = cacheEnabled;
	} 

	public void setContext(Map<String, Object> context) {
		this.prepareJavaContext(context);
	}
	
	public void setUrlPrefix(String urlPrefix) {
		this.urlPrefix = urlPrefix;
	}
	
	public void setConfPath(String confPath) {
		this.confPath = confPath;
	}
	 
    public String getConfJsFilePath() {
        return this.absoluteBasePath+"/" + this.confPath + "/" + CONF_FILE;
    }
    public String getProxyJsFilePath() {
    	return this.absoluteBasePath+"/"+ this.confPath + "/" + PROXY_FILE;
    }
} 